前面十六天,我寫的程式全都在等人。按下按鈕才攻擊、輸入名字才出征——沒人動它,它就安靜地躺著。今天學的東西不一樣:它自己會動。
學完之後我想到一個比喻,剛好把前後的差別講清楚:
| 誰決定觸發時機 | 對應 | |
|---|---|---|
| 地雷 | 玩家(踩了才爆) | addEventListener |
| 定時炸彈 | 時鐘(時間到就爆,沒人踩也爆) | setTimeout |
| 連環炸彈 | 時鐘,而且會一直爆下去 | setInterval |
前十六天我埋的全是地雷。今天第一次出現「不需要任何人操作,自己就會發生的事」。
setTimeout(函式, 毫秒); // 等一段時間,執行一次
setInterval(函式, 毫秒); // 每隔一段時間,重複執行
裡面那個 () => { ... } 是箭頭函式,今天的新語法:
function () { console.log("嗨"); } // 舊寫法
() => { console.log("嗨"); } // 箭頭函式,同一件事
就是「沒有名字的函式」,用在只寫一次、懶得取名的場合。
console.log("A");
setTimeout(() => { console.log("B(1 秒後)"); }, 1000);
console.log("C");
輸出是 A → C → B(1 秒後)。

setTimeout 不是「停在這裡等一秒」,而是預約一件事,然後程式立刻往下跑。我猜對了這題,因為前面幾天已經習慣「程式不會停下來等」的思路。
console.log("1");
setTimeout(() => { console.log("2(延遲 0 毫秒)"); }, 0);
console.log("3");
延遲設成 0 毫秒。我猜輸出會是 1 → 2 → 3——既然不用等,那它應該就在原地執行吧?
實際結果是 1 → 3 → 2。

原因是 JavaScript 是單執行緒的(Day 7 那次無窮迴圈讓整個分頁卡死,就是這件事)。setTimeout 真正的意思不是「等 N 毫秒然後插隊」,而是:
把這件事放到待辦清單的最後面。等我把現在手上這段程式從頭跑完,再去看清單。
所以 0 毫秒的意思是「越快越好,但一定要等現在的工作做完」。它永遠不可能插進 1 和 3 中間。
用炸彈的比喻:setTimeout(fn, 0) 是一顆引線長度 0 的炸彈,但拆彈小組手上還有事,要等他們忙完才會過來點火。
這個機制叫事件迴圈(event loop),是 JavaScript 最核心的概念之一。我用三行程式和一個錯誤的猜測撞出了它。
而且它有實際用途:要做一件很重的計算時,可以用 setTimeout(..., 0) 把它丟到清單後面,讓畫面先更新完再開始算——不然畫面會卡住,就像 Day 7 那次。
setInterval 一定要有人剪線let count = 3;
const timerId = setInterval(() => {
console.log("倒數:", count);
count = count - 1;
if (count === 0) {
clearInterval(timerId);
console.log("時間到!");
}
}, 1000);

我一開始以為這個炸彈會「自己炸完」,但其實不是——setInterval 沒有次數上限,不會自然結束。上面那個 if 做的事還是剪線,只是換成炸彈自己剪自己。
結局永遠只有一種:有人剪線。差別只在於是外面的人剪,還是它自己剪。
這個認知很重要,因為它對應一個真實的 bug:如果 count 因為某個原因跳過了 0(例如變成 -1),那個 if (count === 0) 永遠不成立,炸彈就永遠停不下來。寫成 count <= 0 會安全一點——Day 4 的邊界值問題又出現了。
timerId 是號碼牌,不是我想的那個 3執行時我順手印了 timerId,結果是 3。當時倒數也剛好從 3 開始,我一度以為它們有關係。
其實完全是巧合。setInterval 回傳的是瀏覽器發的一張號碼牌,從 1 開始遞增。我前面做了兩個 setTimeout 練習,所以這個拿到 3 號。
用炸彈比喻:號碼牌就是拆彈時要報的編號,clearInterval(timerId) 等於說「請拆掉 3 號炸彈」。沒有號碼牌就拆不了,這也是為什麼一定要用變數把它接住。
還有一個細節:那個 3 是立刻印出來的,而「倒數: 3」是一秒後才出現。因為 setInterval 做的事只是登記炸彈、發號碼牌,然後馬上返回。
let n = 0;
setInterval(() => {
n = n + 1;
console.log("我停不下來:", n);
}, 1000);

數到 11 還在跑,而且畫面完全正常、沒有任何錯誤訊息。
這跟 Day 7 那次無窮迴圈完全不同。那次是當機:風扇狂轉、網頁卡死、瀏覽器跳出警告——大聲失敗,你一定會發現。今天這個是在背景安靜地永遠執行,沒有任何症狀。
又一個安靜失敗,而且是最陰險的一種。如果那個函式裡有耗資源的操作(打 API、重算畫面),使用者的電腦會慢慢變慢,而且找不到原因。
還有一個延伸的坑:如果玩家連按兩次「開始倒數」,而程式每次都呼叫一次 setInterval,你就埋了兩顆炸彈,但只記得住最後一顆的號碼牌。第一顆會永遠爆下去,沒人拆得掉。現象是「倒數突然變兩倍快」——這在真實世界很常見。
學完之後,骨架上留給我的問題是:這個遊戲要不要用計時器?
我列了幾個可能的用途,然後發現一個判斷標準:我做的是回合制遊戲,而回合制裡「時間」的單位是回合,不是秒。
| 想法 | 決定 | 理由 |
|---|---|---|
| 中毒狀態每秒扣血 | ❌ 不做 | 這是即時制的機制,放進回合制會很怪 |
| 小怪反擊延遲 0.5 秒 | ❌ 不做 | 同上,而且那 0.5 秒玩家能不能操作又是一個問題 |
| 限時挑戰(倒數計時) | ❌ 改設計 | 見下一段 |
| 受擊震動、血量閃爍 | ✅ 之後做 | 這是表現,不是規則 |
整理成一句話就是:
計時器用在「表現層」沒問題,用在「規則層」會破壞回合制。
震動、閃爍、淡出這些效果不影響遊戲規則,只是讓玩家看得更清楚;而中毒扣血、反擊延遲會讓「秒」變成遊戲的一部分,那就不是回合制了。
我原本想做一個隱藏關卡:通關之後可以挑戰「最快通關時間」,倒數計時,時間到沒打完算失敗。
但寫下來的當下我就擔心一件事:這樣最後會不會變成比誰點得快?
想了一下,答案是會。而且解法就藏在我自己前面說的話裡——這是回合制遊戲,所以挑戰的單位不該是秒,而是回合:
| 設計 | 玩家實際在比什麼 |
|---|---|
| 最快通關時間 | 手速 |
| 最少回合通關 | 運氣 + 策略 |
「用最少回合打倒小怪」:傷害有浮動(Day 16 加的),所以每場的最少回合數天生不同;之後如果加了技能或道具,就變成策略選擇。而且點快點慢完全一樣,手速在這裡沒有意義。
這才是回合制遊戲該有的挑戰形式。存最高紀錄需要把資料留住,那是 Day 20 的 localStorage,所以這個隱藏關卡我留到那之後再做。
今天搞懂的事:
setTimeout 預約一次、setInterval 重複執行;兩者都不會卡住程式
() => {} 是沒有名字的函式setTimeout(fn, 0) 不會插隊,它排在待辦清單最後面——這就是事件迴圈setInterval 不會自己停,一定要有人 clearInterval;它回傳的號碼牌要接住才拆得掉setInterval 會疊出多顆停不掉的炸彈這是第一次我學完一個東西,結論是「知道它能做什麼,然後決定不用」。感覺比硬塞進去踏實。
明天要學非同步——今天那個「排到清單最後面」的概念,明天會變成主角。